﻿// --------------------------------------------------------------------------------------------------------------------
// <copyright file="DownloadTask.cs" company="Mar3ek">
//   Copyright (c) Mar3ek 2012
// </copyright>
// <summary>
//   Download task.
// </summary>
// --------------------------------------------------------------------------------------------------------------------

namespace DownloaderCore
{
    using System;
    using System.ComponentModel;
    using System.IO;
    using System.Net;
    using System.Net.Http;
    using System.Net.Http.Headers;
    using System.Runtime.Serialization;
    using System.Threading;
    using System.Threading.Tasks;

    using DownloaderCore.Exceptions;

    /// <summary> Download task. </summary>
    /// <remarks> Mar3ek, 11/4/2012. </remarks>
    [DataContract(Name = "DownloadTask", Namespace = "http://mdm2.codeplex.com")]
    public class DownloadTask : INotifyPropertyChanged, IDisposable
    {
        /// <summary> The download handler. </summary>
        private readonly HttpClientHandler downloadHandler = new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = true, AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, UseCookies = true };

        /// <summary> The download client. </summary>
        private readonly HttpClient downloadClient;

        /// <summary> Backing field for IsSSL property. </summary>
        private bool isSsl;

        /// <summary> Backing field for IsActive property. </summary>
        private bool isActive;

        /// <summary> Backing field for IsActive property. </summary>
        private bool isFinished;

        /// <summary> Backing field for RemainingTime property. </summary>
        private long remainingTime;

        /// <summary> Backing field for SupportsResuming property. </summary>
        private bool supportsResuming;

        /// <summary> Backing field for DownloadedSize property. </summary>
        private long downloadedSize;

        /// <summary> Backing field for CurrentSpeed property. </summary>
        private long currentSpeed;

        /// <summary> Backing field for AverageSpeed property. </summary>
        private long averageSpeed;

        /// <summary> Backing field for Progress property. </summary>
        private int progress;

        /// <summary> Indicates whether this instance has already been disposed. </summary>
        private bool isDisposed;

        /// <summary> Initializes a new instance of the <see cref="DownloadTask"/> class. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <param name="address"> The URL of the file to download. </param>
        /// <param name="fileName"> The name of the destination file. </param>
        public DownloadTask(Uri address, string fileName)
        {
            this.OriginalUrl = address;
            this.FileName = fileName;
            this.TimeCreated = DateTime.Now;
            this.downloadClient = new HttpClient(this.downloadHandler);
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="DownloadTask"/> class. Only for testing purposes, will be discarded. 
        /// </summary>
        /// <remarks>
        /// Mar3ek, 11/6/2012. 
        /// </remarks>
        public DownloadTask()
        {
        }

        /// <summary> Event queue for all listeners interested in PropertyChanged events. </summary>
        public event PropertyChangedEventHandler PropertyChanged;

        /// <summary> Gets the filename of the target file. </summary>
        /// <value> The filename. </value>
        [DataMember]
        public string FileName { get; private set; }

        /// <summary> Gets the size of the download. </summary>
        /// <value> The download size. </value>
        [DataMember]
        public long Size { get; private set; }

        /// <summary> Gets the download progress. </summary>
        /// <value> The download progress. </value>
        [DataMember]
        public int Progress
        {
            get
            {
                return this.progress;
            }

            private set
            {
                this.progress = value;
                this.OnPropertyChanged("Progress");
            }
        }

        /// <summary> Gets the average download speed. </summary>
        /// <value> The average download speed. </value>
        [DataMember]
        public long AverageSpeed
        {
            get
            {
                return this.averageSpeed;
            }

            private set
            {
                this.averageSpeed = value;
                this.OnPropertyChanged("AverageSpeed");
            }
        }

        /// <summary> Gets the current download speed. </summary>
        /// <value> The current download speed. </value>
        public long CurrentSpeed
        {
            get
            {
                return this.currentSpeed;
            }

            private set
            {
                this.currentSpeed = value;
                this.OnPropertyChanged("CurrentSpeed");
            }
        }

        /// <summary> Gets the size of the downloaded data. </summary>
        /// <value> The size of the downloaded data. </value>
        [DataMember]
        public long DownloadedSize
        {
            get
            {
                return this.downloadedSize;
            }

            private set
            {
                this.downloadedSize = value;
                this.OnPropertyChanged("DownloadedSize");
            }
        }

        /// <summary> Gets a value indicating whether this download supports resuming. </summary>
        /// <value> true if supports resuming, false if not. </value>
        public bool SupportsResuming
        {
            get
            {
                return this.supportsResuming;
            }

            private set
            {
                this.supportsResuming = value;
                this.OnPropertyChanged("SupportsResuming");
            }
        }

        /// <summary> Gets the estimated remaining time. </summary>
        /// <value> The estimated remaining time. </value>
        public long RemainingTime
        {
            get
            {
                return this.remainingTime;
            }

            private set
            {
                this.remainingTime = value;
                this.OnPropertyChanged("RemainingTime");
            }
        }

        /// <summary> Gets a value indicating whether this download is active. </summary>
        /// <value> true if this download is active, false if not. </value>
        public bool IsActive
        {
            get
            {
                return this.isActive;
            }

            private set
            {
                this.isActive = value;
                this.OnPropertyChanged("IsActive");
            }
        }

        /// <summary> Gets a value indicating whether this download is finished. </summary>
        /// <value> true if this download is finished, false if not. </value>
        public bool IsFinished
        {
            get
            {
                return this.isFinished;
            }

            private set
            {
                this.isFinished = value;
                this.OnPropertyChanged("IsFinished");
            }
        }

        /// <summary> Gets a value indicating whether this download is using an encrypted channel. </summary>
        /// <value> true if this download uses SSL, false if not. </value>
        public bool IsSsl
        {
            get
            {
                return this.isSsl;
            }

            private set
            {
                this.isSsl = value;
                this.OnPropertyChanged("IsSSL");
            }
        }

        /// <summary> Gets the original URL of this download. </summary>
        /// <value> The original URL. </value>
        [DataMember]
        public Uri OriginalUrl { get; private set; }

        /// <summary> Gets the time when this download was created. </summary>
        /// <value> The creation time of this download. </value>
        [DataMember]
        public DateTime TimeCreated { get; private set; }

        /// <summary> Gets the time of completion. </summary>
        /// <value> The time of completion. </value>
        [DataMember]
        public DateTime TimeFinished { get; private set; }

        /// <summary> Gets or sets a method to be called when this tasks starts. </summary>
        /// <value> A method delegate. </value>
        public Action<DownloadTask> TaskStartedDelegate { get; set; }

        /// <summary> Gets or sets a method to be called when this tasks is cancelled. </summary>
        /// <value> A method delegate. </value>
        public Action<DownloadTask> TaskInterruptedDelegate { get; set; }

        /// <summary> Gets or sets a method to be called when this tasks finishes. </summary>
        /// <value> A method delegate. </value>
        public Action<DownloadTask> TaskFinishedDelegate { get; set; }

        /// <summary> Gets or sets a method to be called when attempting to authorize a download request. </summary>
        /// <value> A method delegate. </value>
        public Func<Uri, Cookie> AuthorizationDelegate { get; set; }

        /// <summary> Gets or sets the class that provides access to platform-dependent IO operations. </summary>
        /// <value> The file tools. </value>
        public IPlatformFileToolkit FileTools { get; set; }

        /// <summary> Gets or sets the name. Only for testing purposes, will be discarded. </summary>
        /// <value> The name. </value>
        public string Name { get; set; }

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged
        /// resources.
        /// </summary>
        /// <remarks> Mar3ek, 11/6/2012. </remarks>
        public void Dispose()
        {
            this.Dispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary> Starts the given token. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <param name="token"> The token. </param>
        /// <returns> Task representing the download operation. </returns>
        internal async Task Start(CancellationToken token)
        {
            try
            {
                this.IsActive = true;

                if (this.TaskStartedDelegate != null)
                {
                    this.TaskStartedDelegate(this);
                }

                await this.DownloadDataAsync(token);

                this.IsFinished = true;

                if (this.TaskFinishedDelegate != null)
                {
                    this.TaskFinishedDelegate(this);
                }
            }
            catch (OperationCanceledException)
            {
                if (this.TaskInterruptedDelegate != null)
                {
                    this.TaskInterruptedDelegate(this);
                }
            }
        }

        /// <summary> Verifies the validity of the provided download link. </summary>
        /// <remarks> Mar3ek, 11/6/2012. </remarks>
        /// <param name="token"> The cancellation token. </param>
        /// <returns> True if the link is alive, otherwise false. </returns>
        internal async Task<bool> VerifyDownloadLink(CancellationToken token)
        {
            if (this.AuthorizationDelegate != null)
            {
                this.downloadHandler.CookieContainer.Add(this.OriginalUrl, this.AuthorizationDelegate(this.OriginalUrl));
            }

            var downloadRequestMessage = new HttpRequestMessage(HttpMethod.Get, this.OriginalUrl);

            try
            {
                using (var response = await this.downloadClient.SendAsync(downloadRequestMessage, HttpCompletionOption.ResponseHeadersRead, token))
                {
                    if (response.Content.Headers.ContentLength == null || response.Content.Headers.ContentLength == -1)
                    {
                        throw new NoContentException("The link does not point to a valid file - ContentLength == -1");
                    }

                    this.Size = response.Content.Headers.ContentLength.Value;
                    this.SupportsResuming = response.Headers.AcceptRanges.Contains("bytes");
                    this.IsSsl = response.RequestMessage.RequestUri.Scheme == "https";
                    return true;
                }
            }
            catch (Exception)
            {
                this.Size = -1;
                this.SupportsResuming = false;
                this.IsSsl = false;
                return false;
            }
        }

        /// <summary> Executes the property changed action. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <param name="propertyName"> Name of the property. </param>
        protected virtual void OnPropertyChanged(string propertyName)
        {
            var handler = this.PropertyChanged;
            if (handler != null)
            {
                handler(this, new PropertyChangedEventArgs(propertyName));
            }
        }

        /// <summary> Starts the asynchronous download process. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <param name="token"> The cancellation token. </param>
        /// <returns> A task describing the download operation. </returns>
        private async Task DownloadDataAsync(CancellationToken token)
        {
            try
            {
                await this.VerifyDownloadLink(token);

                if (this.AuthorizationDelegate != null)
                {
                    this.downloadHandler.CookieContainer.Add(this.OriginalUrl, this.AuthorizationDelegate(this.OriginalUrl));
                }

                var alreadyDownloaded = this.GetAlreadyDownloadedSize();

                var downloadRequestMessage = new HttpRequestMessage(HttpMethod.Get, this.OriginalUrl);
                downloadRequestMessage.Headers.Range = new RangeHeaderValue(alreadyDownloaded, null);

                using (var response = await this.downloadClient.SendAsync(downloadRequestMessage, HttpCompletionOption.ResponseHeadersRead, token))
                {
                    if (response.Content.Headers.ContentLength == null || response.Content.Headers.ContentLength == -1)
                    {
                        throw new NoContentException("The link does not point to a valid file - ContentLength == -1");
                    }

                    this.Size = alreadyDownloaded + response.Content.Headers.ContentLength.Value;
                    this.SupportsResuming = response.Headers.AcceptRanges.Contains("bytes");
                    this.IsSsl = response.RequestMessage.RequestUri.Scheme == "https";
                    var dataStream = await response.Content.ReadAsStreamAsync();

                    this.ReceiveData(dataStream, this.FileTools.OpenFile(this.FileName), alreadyDownloaded, this.Size, token);
                }
            }
            catch (TaskCanceledException)
            {
                System.Diagnostics.Debug.WriteLine("Task cancelled - " + this.OriginalUrl);
            }
            finally
            {
                this.IsActive = false;
            }
        }

        /// <summary> Receives data. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <exception cref="OperationCanceledException"> Thrown when the operation is cancelled.
        /// </exception>
        /// <param name="sourceStream"> Stream to read data from. </param>
        /// <param name="destinationStream"> Stream to write data to. </param>
        /// <param name="alreadyDownloaded"> The already downloaded size. </param>
        /// <param name="fullSize"> Full size of the download. </param>
        /// <param name="token"> The cancellation token. </param>
        private void ReceiveData(Stream sourceStream, Stream destinationStream, long alreadyDownloaded, long fullSize, CancellationToken token)
        {
            using (sourceStream)
            using (destinationStream)
            {
                const int BufferSize = 204800;

                var downloadStart = DateTime.Now;
                var secondStart = DateTime.Now;
                var totalData = alreadyDownloaded;
                var dataInSecond = 0L;
                var numberOfNulls = 0;
                const int NumberOfNullsSensitivity = 1000;

                var buffer = new byte[BufferSize]; // read buffer (200 kB)
                var loaded = sourceStream.Read(buffer, 0, BufferSize);

                DateTime currenttime;
                double totalElapsedSeconds;
                double timeFromLastSecond;
                long computedAverageSpeed;

                while (totalData != fullSize)
                {
                    if (token.IsCancellationRequested)
                    {
                        throw new OperationCanceledException(token);
                    }

                    currenttime = DateTime.Now;
                    totalElapsedSeconds = currenttime.Subtract(downloadStart).TotalSeconds;
                    computedAverageSpeed = (long)Math.Round((totalData - alreadyDownloaded) / totalElapsedSeconds, 0);

                    try
                    {
                        if (loaded > 0)
                        {
                            // write buffer to destination file
                            destinationStream.Write(buffer, 0, loaded);
                        }
                        else
                        {
                            numberOfNulls++;

                            if (numberOfNulls >= NumberOfNullsSensitivity)
                            {
                                // server is sending no data
                                throw new EmptyStreamResponseException("The server is returning no data.");
                            }
                        }
                    }
                    catch (IOException ex)
                    {
                        throw new DataWritingException("Unable to write downloaded data to disk.", ex);
                    }

                    totalData += loaded; // increment loaded data counter
                    dataInSecond += loaded;
                    loaded = sourceStream.Read(buffer, 0, BufferSize);

                    timeFromLastSecond = currenttime.Subtract(secondStart).TotalSeconds;
                    if (timeFromLastSecond > 1)
                    {
                        if (this.UpdateProperties(fullSize, totalData, timeFromLastSecond, computedAverageSpeed, ref dataInSecond, out secondStart))
                        {
                            return;
                        }
                    }
                }
            }
        }

        /// <summary> Updates the download properties. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <param name="fullSize"> Full size of the download. </param>
        /// <param name="totalData"> Total number of bytes already downloaded. </param>
        /// <param name="timeFromLastSecond"> The time from last second. </param>
        /// <param name="computedAverageSpeed"> The computed average speed. </param>
        /// <param name="dataInSecond"> [in,out] Data downloaded in the last second. </param>
        /// <param name="secondStart"> [out] Time of the next measurement. </param>
        /// <returns> true if it succeeds, false if it fails. </returns>
        private bool UpdateProperties(
            long fullSize,
            long totalData,
            double timeFromLastSecond,
            long computedAverageSpeed,
            ref long dataInSecond,
            out DateTime secondStart)
        {
            this.DownloadedSize = totalData;

            this.CurrentSpeed = (long)Math.Round(dataInSecond / timeFromLastSecond, 0);

            dataInSecond = 0;
            secondStart = DateTime.Now;

            this.AverageSpeed = computedAverageSpeed;

            if (fullSize > 0 && computedAverageSpeed != 0)
            {
                this.RemainingTime = (fullSize - totalData) / computedAverageSpeed;
            }
            else
            {
                this.RemainingTime = 0;
            }

            if (fullSize <= 0)
            {
                return true;
            }

            this.Progress = (int)((double)totalData / fullSize) * 100;
            return false;
        }

        /// <summary> Gets the already downloaded size. </summary>
        /// <remarks> Mar3ek, 11/4/2012. </remarks>
        /// <exception cref="Exception"> Thrown when an error condition occurs. </exception>
        /// <returns> The already downloaded size. </returns>
        private long GetAlreadyDownloadedSize()
        {
            if (this.FileTools.FileExists(this.FileName))
            {
                // if file exists, start downloading where we finished the last time
                if (this.SupportsResuming)
                {
                    var alreadyDownloaded = this.FileTools.GetFileSize(this.FileName);

                    if (alreadyDownloaded < this.Size)
                    {
                        return alreadyDownloaded;
                    }

                    if (alreadyDownloaded == this.Size)
                    {
                        // downloaded size equals full size of the file
                        this.TimeFinished = DateTime.Now;
                        return alreadyDownloaded;
                    }

                    // file data mismatch (possibility to re-download from scratch)
                    throw new ContentSizeMismatchException("The already downloaded portion of the file does not match the size advertised by the server.");
                }

                this.DownloadedSize = 0;
                this.FileTools.DeleteFile(this.FileName);
            }

            return 0;
        }

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged
        /// resources.
        /// </summary>
        /// <remarks> Mar3ek, 11/6/2012. </remarks>
        /// <param name="disposing"> true to release both managed and unmanaged resources; false to
        /// release only unmanaged resources.
        /// </param>
        private void Dispose(bool disposing)
        {
            if (this.isDisposed)
            {
                return;
            }

            if (disposing)
            {
                this.downloadClient.Dispose();
                this.downloadHandler.Dispose();
            }

            this.isDisposed = true;
        }
    }
}
